从 Token 流到 AST
上一节实现了 tokenizer,将模板字符串拆分为 Token 流。这一节将 Token 流构建为 AST(抽象语法树)。
AST 的构建过程本质上是对 Token 列表进行顺序扫描,同时维护一个**栈(stack)**来记录元素间的父子关系。
核心数据结构
AST 节点类型
interface ASTNode {
type: 'Root' | 'Element' | 'Text'
tag?: string // Element 节点的标签名
content?: string // Text 节点的文本内容
children: ASTNode[] // 子节点数组
}
typescript
Token 类型
上一节的 tokenize 输出需要区分标签的开始和结束:
type Token =
| { type: 'tag', name: string } // 开始标签 <p>
| { type: 'text', content: string } // 文本内容
| { type: 'tagEnd', name: string } // 结束标签 </p>
typescript
parse 函数实现
function parse(str: string): ASTNode {
const tokens = tokenize(str)
// 创建根节点
const root: ASTNode = { type: 'Root', children: [] }
// 维护一个栈,用于追踪父子关系
const elementStack: ASTNode[] = [root]
while (tokens.length > 0) {
// 当前栈顶元素就是父节点
const parent = elementStack[elementStack.length - 1]
const token = tokens[0]
switch (token.type) {
case 'tag':
// 开始标签 → 创建 Element 节点
const elementNode: ASTNode = {
type: 'Element',
tag: token.name,
children: [],
}
// 加入父节点的 children
parent.children.push(elementNode)
// 压入栈(后续的子节点会以它为父节点)
elementStack.push(elementNode)
break
case 'text':
// 文本 → 创建 Text 节点,直接加入父节点的 children
const textNode: ASTNode = {
type: 'Text',
content: token.content,
children: [],
}
parent.children.push(textNode)
break
case 'tagEnd':
// 结束标签 → 弹出栈顶元素(关闭该元素的子节点收集)
elementStack.pop()
break
}
// 消费已处理的 token
tokens.shift()
}
return root
}
typescript
栈的工作原理
以 <div><p>Vue</p><p>template</p></div> 为例:
处理过程:
Token: tag(div)
栈: [Root]
操作: 创建 div 节点,加入 Root.children,div 压栈
栈: [Root, div]
Token: tag(p)
栈: [Root, div]
操作: 创建 p 节点,加入 div.children,p 压栈
栈: [Root, div, p]
Token: text("Vue")
栈: [Root, div, p]
操作: 创建 text 节点,加入 p.children
栈: [Root, div, p]
Token: tagEnd(p)
栈: [Root, div, p]
操作: 弹出 p
栈: [Root, div]
Token: tag(p)
栈: [Root, div]
操作: 创建 p 节点,加入 div.children,p 压栈
栈: [Root, div, p]
Token: text("template")
操作: 创建 text 节点,加入 p.children
Token: tagEnd(p)
操作: 弹出 p
栈: [Root, div]
Token: tagEnd(div)
操作: 弹出 div
栈: [Root]
text
最终生成的 AST:
{
"type": "Root",
"children": [
{
"type": "Element",
"tag": "div",
"children": [
{
"type": "Element",
"tag": "p",
"children": [
{ "type": "Text", "content": "Vue", "children": [] }
]
},
{
"type": "Element",
"tag": "p",
"children": [
{ "type": "Text", "content": "template", "children": [] }
]
}
]
}
]
}
json
为什么 Vue 模板的 AST 构建相对简单
与通用编程语言的解析器不同,Vue 模板 AST 的构建没有运算符优先级的问题。模板只涉及标签嵌套和文本内容,不需要处理表达式优先级(如 * 优先于 +),因此栈 + 顺序扫描就足够了。
| 对比 | 通用语言解析器 | Vue 模板解析器 |
|---|---|---|
| 优先级 | 需要处理运算符优先级 | 无优先级问题 |
| 递归 | 可能需要递归下降 | 简单栈即可 |
| 复杂度 | 较高 | 相对较低 |
辅助工具:dump 函数
用于可视化打印 AST 的树形结构:
function dump(node: ASTNode, indent = 0): void {
const type = node.type
const desc = type === 'Root'
? ''
: type === 'Element'
? `<${node.tag}>`
: node.content
console.log(`${'--'.repeat(indent)}${desc}`)
if (node.children) {
node.children.forEach(child => dump(child, indent + 1))
}
}
typescript
本节要点
- parse 函数通过对 Token 列表进行顺序扫描来构建 AST
- **栈(elementStack)**用于维护元素间的父子关系——遇到开始标签压栈,遇到结束标签弹栈
- 栈顶元素始终是当前节点的父节点
- Vue 模板没有运算符优先级问题,因此 AST 构建相对简单
- 完成解析后,后续步骤是 transform(AST 转换) 和 插件化架构设计
↑